package org.melanesia.sql;

import java.sql.CallableStatement;
import java.sql.Connection;
import java.util.HashMap;
import java.util.Map;

import org.melanesia.beans.BeanUtils;
import org.melanesia.converters.JavaToJavaConverter;
import org.melanesia.converters.JavaToJdbcConverter;
import org.melanesia.converters.TypeConverter;
import org.melanesia.sql.exceptions.SQLExecutionException;
import org.melanesia.sql.utils.JDBCUtils;

/**
 * Call class allows for sql-call (stored procedures and functions) execution.
 *
 * @author marcin.kielar
 */
public final class Call extends AbstractSQL {

    /** Call metadata. */
    private final CallMetaData callMetaData = new CallMetaData();

    /** Bean classes, for instantiating intermediate properties. */
    private final Map<String, Class<?>> beanClasses = new HashMap<String, Class<?>>();

    /**
     * Constructs new <code>Call</code>.
     *
     * @param connection
     *            jdbc connection
     * @param typeConverter
     *            type converter
     * @param sql
     *            sql clause
     */
    Call(final Connection connection, final TypeConverter typeConverter, final String sql) {
        super(connection, typeConverter, sql);
    }

    /**
     * Sets the call input parameter value.
     *
     * @param name
     *            parameter name
     * @param value
     *            parameter value
     * @return this query
     */
    public Call setParameter(final String name, final Object value) {
        callMetaData.setInputParameter(name, value);
        return this;
    }

    /**
     * Sets the call input parameter value and explicitly defines java do jdbc
     * converter.
     *
     * @param name
     *            parameter name
     * @param value
     *            parameter value
     * @param converter
     *            java to jdbc converter
     * @return this query
     */
    public Call setParameter(final String name, final Object value, final JavaToJdbcConverter converter) {
        callMetaData.setInputParameter(name, value, converter);
        return this;
    }

    /**
     * Registers output parameter for this call.
     *
     * @param name
     *            output parameter name (this can also be a composite property)
     * @param parameterClass
     *            parameter class
     * @return this call
     */
    public Call registerOutputParameter(final String name, final Class<?> parameterClass) {
        callMetaData.registerOutputParameter(name, parameterClass);
        return this;
    }

    /**
     * Registers output parameter for this call and explicitly defines java to
     * java converter for its value.
     *
     * @param name
     *            output parameter name (this can also be a composite property)
     * @param parameterClass
     *            parameter class
     * @param converter
     *            java to java converter
     * @return this call
     */
    public Call registerOutputParameter(final String name, final Class<?> parameterClass, final JavaToJavaConverter converter) {
        callMetaData.registerOutputParameter(name, parameterClass, converter);
        return this;
    }

    /**
     * See {@link QueryMetaData#setPropertyPathForResult(String, String)}.
     *
     * @param outputParameter column name
     * @param propertyPath property path in result java bean
     * @return this call
     */
    public Call setPropertyPathForOutputParameter(final String outputParameter, final String propertyPath) {
        callMetaData.setPropertyPathForResult(outputParameter, propertyPath);
        return this;
    }

    /**
     * Sets class for a particular bean property This is mainly used with
     * composite properties. The idea of composite properties is discussed in
     * {@link org.melanesia.beans.Setter#set(Object, Object, Map)}.
     *
     * @param bean
     *            name of the beanProperty used in SQL statement. For example:
     *            "b", "b.c", "b.c.d"
     * @param beanClass
     *            class to be used when instantiating the bean.
     * @return this query.
     */
    public Call setPropertyBeanClass(final String bean, final Class<?> beanClass) {
        beanClasses.put(bean, beanClass);
        return this;
    }

    /**
     * Executes the call. No values are returned. If call had any registered
     * output parameters, their values are ignored.
     */
    public void execute() {
        executeInternal(null, null);
    }

    /**
     * Executes the call returning a bean of class
     * <code>returnedBeanClass</code> representing the result of the call.
     *
     * @param <T>
     *            returned type
     * @param returnedBeanClass
     *            returned class
     * @return bean representing the call result
     */
    public <T> T execute(final Class<T> returnedBeanClass) {
        return executeInternal(returnedBeanClass, null);
    }

    /**
     * Executes the call returning only the value of the selected output
     * parameter from the call result.
     *
     * NOTE! This method SHOULD be given an Object type (not a primitive type)
     * in the <code>returnedClass</code>.
     *
     * If a primitive type is given (i.e. int.class, boolean.class), and there
     * is no result, or the result is null, this method will cause an
     * {@link org.melanesia.beans.exceptions.NullCastToPrimitiveTypeException},
     * as described in {@link org.melanesia.beans.BeanUtils#cast(Object, Class)}
     *
     * The user MAY pass a primitive type if she/he is sure that there will
     * always be a not null result or if she/he is aware of the
     * {@link org.melanesia.beans.exceptions.NullCastToPrimitiveTypeException}
     * and handles it in user code.
     *
     * It is however advised not to use primitive types at all with this method.
     *
     * @param <T>
     *            object type of returned value
     * @param returnedBeanClass
     *            returned class
     * @param resultOutputParameter
     *            name of the output parameter from call result to return
     * @return value of the column specified in <code>returnColumnName</code>
     */
    public <T> T execute(final Class<T> returnedBeanClass, final String resultOutputParameter) {
        return executeInternal(returnedBeanClass, resultOutputParameter);
    }

    /**
     * Internal method for executing calls.
     *
     * @param <T>
     *            return type
     * @param returnedClass
     *            return class
     * @param returnOutputParameter
     *            name of the output parameter, for single parameter retrieval
     * @return result of the call
     */
    public <T> T executeInternal(final Class<T> returnedClass, final String returnOutputParameter) {

        CallableStatement stmt = null;

        try {

            stmt = getConnection().prepareCall(getSql());

            // Input parameters
            for (String parameterName : callMetaData.getInputParameterNames()) {
                InputParameter parameter = callMetaData.getInputParameter(parameterName);
                bindInputParameter(stmt, parameter);
            }

            // Output parameters
            for (String parameterName : callMetaData.getOutputParameterNames()) {
                OutputParameter parameter = callMetaData.getOutputParameter(parameterName);
                bindOutputParameter(stmt, parameter);
            }

            stmt.execute();

            T result = null;

            if (returnedClass != null) {

                if (returnOutputParameter != null) {

                    // We have a specified output parameter to return,
                    // and ignore the rest.

                    Object returnOutputParameterValue = getOutputParameterRawValue(stmt, returnOutputParameter);
                    result = bindResultToSimpleValue(returnedClass, returnOutputParameter, returnOutputParameterValue);

                } else {

                    result = BeanUtils.newInstance(returnedClass);

                    for (String parameterName : callMetaData.getOutputParameterNames()) {
                        Object resultValue = getOutputParameterRawValue(stmt, parameterName);
                        String resultName = callMetaData.getPropertyPathForResult(parameterName);
                        bindResultToBean(result, resultName, resultValue, beanClasses);
                    }
                }
            }

            return result;
        } catch (Throwable t) {
            throw new SQLExecutionException(t);
        } finally {
            JDBCUtils.close(stmt);
        }
    }
}